--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit bbc4f3bd218fd44903c22ecdee4213b255af78c2
Parents : 6f69e86
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-09T14:15:06-05:00
feat(rnsh): update RNSH session management with improved output handling, error notifications, and support for no-auth connections
Changes
14 files changed, 617 insertions(+), 42 deletions(-)
Diff
diff --git a/meshchatx/src/backend/rnsh_manager.py b/meshchatx/src/backend/rnsh_manager.py
index 924fd77b..4e5bf0e4 100644
--- a/meshchatx/src/backend/rnsh_manager.py
+++ b/meshchatx/src/backend/rnsh_manager.py
@@ -40,7 +40,11 @@ DEFAULT_TERMINAL_COLS = 120
# Matches the listener address that rnsh logs on startup, e.g.
# "rnsh listening for commands on <a1b2c3...>" or "Listening on : <...>".
-_LISTEN_ADDRESS_RE = re.compile(r"<([0-9a-fA-F]{16,})>")
+# Require the listening phrase so verbose identity/transport hashes are ignored.
+_LISTEN_ADDRESS_RE = re.compile(
+ r"listening(?:\s+for\s+commands)?\s+on\s*:?\s*<([0-9a-fA-F]{16,})>",
+ re.IGNORECASE,
+)
_RNSH_MODULE = "RNS.Utilities.rnsh.rnsh"
# cx_Freeze / AppImage bundles set sys.executable to MeshChatX itself, which
@@ -200,6 +204,7 @@ class RNSHSession:
with self._lock:
self._output_chunks.clear()
self._output_text = ""
+ self._output_seq = 0
self.updated_at = time.time()
self.manager._on_session_change(self)
self.manager.save()
@@ -207,6 +212,7 @@ class RNSHSession:
def append_output(self, text):
if not isinstance(text, str) or not text:
return None
+ listen_address_changed = False
with self._lock:
self._output_seq += 1
chunk = {
@@ -219,22 +225,25 @@ class RNSHSession:
if len(self._output_text) > 200000:
self._output_text = self._output_text[-200000:]
self.updated_at = chunk["ts"]
- self._maybe_detect_listen_address()
- return chunk
+ listen_address_changed = self._maybe_detect_listen_address()
+ if listen_address_changed:
+ self.manager._on_session_change(self)
+ return chunk
def _maybe_detect_listen_address(self):
"""Extract the listener destination hash from rnsh log output.
Must be called while holding ``self._lock``.
+ Returns True when a new listen address was stored.
"""
if self.mode != "listen" or self.listen_address:
- return
+ return False
tail = self._output_text[-4000:]
- if "listening" not in tail.lower() and "listening on" not in tail.lower():
- return
match = _LISTEN_ADDRESS_RE.search(tail)
if match:
self.listen_address = match.group(1).lower()
+ return True
+ return False
@staticmethod
def _rnsh_module_available():
@@ -391,24 +400,52 @@ class RNSHSession:
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
def start(self):
+ notify_failure = False
+ failure = None
+ started_process = None
with self._lock:
if self._process is not None and self._process.poll() is None:
return self.to_dict(include_output_tail=True)
- command = self._build_command()
- self._stop_requested = False
- self.last_error = None
- self.last_exit_code = None
- self.last_command = " ".join(shlex.quote(part) for part in command)
-
- if self._supports_pty():
- self._start_with_pty(command)
+ try:
+ command = self._build_command()
+ except Exception as exc:
+ self.status = self.STATUS_FAILED
+ self.last_error = str(exc)
+ self.pid = None
+ self.updated_at = time.time()
+ notify_failure = True
+ failure = exc
else:
- self._start_with_pipe(command)
+ self._stop_requested = False
+ self.last_error = None
+ self.last_exit_code = None
+ self.last_command = " ".join(shlex.quote(part) for part in command)
- self.pid = self._process.pid
- self.status = self.STATUS_RUNNING
- self.updated_at = time.time()
+ try:
+ if self._supports_pty():
+ self._start_with_pty(command)
+ else:
+ self._start_with_pipe(command)
+ except Exception as exc:
+ self.status = self.STATUS_FAILED
+ self.last_error = str(exc)
+ self.pid = None
+ self._process = None
+ self._master_fd = None
+ self.updated_at = time.time()
+ notify_failure = True
+ failure = exc
+ else:
+ self.pid = self._process.pid
+ self.status = self.STATUS_RUNNING
+ self.updated_at = time.time()
+ started_process = self._process
+
+ if notify_failure:
+ self.manager._on_session_change(self)
+ self.manager.save()
+ raise failure
self.manager._on_session_change(self)
self.manager.save()
@@ -416,7 +453,11 @@ class RNSHSession:
reader = threading.Thread(target=self._reader_loop, daemon=True)
reader.start()
- waiter = threading.Thread(target=self._waiter_loop, daemon=True)
+ waiter = threading.Thread(
+ target=self._waiter_loop,
+ args=(started_process,),
+ daemon=True,
+ )
waiter.start()
return self.to_dict(include_output_tail=True)
@@ -463,6 +504,11 @@ class RNSHSession:
process = self._process
self._stop_requested = True
if process is None:
+ with self._lock:
+ if self.status == self.STATUS_RUNNING:
+ self.status = self.STATUS_STOPPED
+ self.pid = None
+ self.updated_at = time.time()
return self.to_dict(include_output_tail=True)
with contextlib.suppress(Exception):
process.terminate()
@@ -471,6 +517,17 @@ class RNSHSession:
except Exception:
with contextlib.suppress(Exception):
process.kill()
+ with contextlib.suppress(Exception):
+ process.wait(timeout=1.0)
+ with self._lock:
+ if self._process is process:
+ self.last_exit_code = process.poll()
+ self.pid = None
+ self._process = None
+ self.status = self.STATUS_STOPPED
+ self.updated_at = time.time()
+ self.manager._on_session_change(self)
+ self.manager.save()
return self.to_dict(include_output_tail=True)
def resize(self, rows, cols):
@@ -552,14 +609,19 @@ class RNSHSession:
self._last_persist = now
self.manager.save()
- def _waiter_loop(self):
- with self._lock:
- process = self._process
+ def _waiter_loop(self, process):
+ """Wait for ``process`` and update status only if it is still current.
+
+ The process is passed in so a later restart cannot be clobbered by an
+ older waiter finishing after a new session process was started.
+ """
if process is None:
return
exit_code = process.wait()
with self._lock:
+ if self._process is not process:
+ return
self.last_exit_code = exit_code
self.pid = None
self._process = None
@@ -676,14 +738,19 @@ class RNSHManager:
"sessions": [session.to_store() for session in self._sessions.values()],
}
path = self._store_path()
- os.makedirs(os.path.dirname(path), exist_ok=True)
- with open(path, "w", encoding="utf-8") as handle:
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
+ tmp_path = f"{path}.tmp"
+ with open(tmp_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
+ handle.flush()
+ with contextlib.suppress(OSError):
+ os.fsync(handle.fileno())
+ os.replace(tmp_path, path)
def list_sessions(self):
with self._lock:
sessions = [
- session.to_dict(include_output_tail=True)
+ session.to_dict(include_output_tail=True, output_tail_size=400)
for session in self._sessions.values()
]
sessions.sort(key=lambda item: item.get("updated_at", 0), reverse=True)
diff --git a/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue b/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue
index 15d1af7c..b41fc06e 100644
--- a/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue
@@ -224,6 +224,12 @@
{{ $t("rnsh.config_dir_hint") }}
</p>
</div>
+ <div class="flex flex-wrap items-center gap-3 sm:gap-4">
+ <label class="flex items-center gap-2 text-xs sm:text-sm text-gray-700 dark:text-gray-300">
+ <input v-model="listenForm.no_auth" type="checkbox" class="rounded-sm" />
+ {{ $t("rnsh.no_auth") }}
+ </label>
+ </div>
<button
type="button"
class="primary-chip px-4 py-2 text-sm w-full sm:w-auto"
@@ -325,6 +331,7 @@ export default {
allowed_hashes_text: "",
command: "",
config_path: "",
+ no_auth: true,
},
isNarrowScreen: false,
mobileSessionsOpen: false,
@@ -466,14 +473,29 @@ export default {
if (!session || !session.id) {
return;
}
+ const existing = this.outputsBySession[session.id] || "";
const chunks = Array.isArray(session.output_chunks) ? session.output_chunks : [];
- if (chunks.length > 0) {
- this.outputsBySession[session.id] = chunks.map((chunk) => chunk.text || "").join("");
- } else if (typeof session.output_text === "string") {
- this.outputsBySession[session.id] = session.output_text;
- } else if (!this.outputsBySession[session.id]) {
- this.outputsBySession[session.id] = "";
+ const fromChunks = chunks.length > 0 ? chunks.map((chunk) => chunk.text || "").join("") : "";
+ const fromText = typeof session.output_text === "string" ? session.output_text : "";
+ // Prefer the longer server buffer so a short chunk tail cannot hide output_text.
+ const incoming = fromText.length >= fromChunks.length ? fromText : fromChunks;
+ if (!incoming) {
+ if (!Object.prototype.hasOwnProperty.call(this.outputsBySession, session.id)) {
+ this.outputsBySession[session.id] = "";
+ }
+ return;
+ }
+ // Keep a longer live WebSocket buffer when a reload returns a truncated tail.
+ if (existing.length > incoming.length) {
+ if (
+ existing.endsWith(incoming) ||
+ (fromChunks && existing.endsWith(fromChunks)) ||
+ (fromChunks && existing.includes(fromChunks))
+ ) {
+ return;
+ }
}
+ this.outputsBySession[session.id] = incoming;
},
async loadSessions() {
try {
@@ -516,6 +538,7 @@ export default {
.filter((value) => value.length > 0),
default_command: (this.listenForm.command || "").trim() || undefined,
config_path: (this.listenForm.config_path || "").trim() || undefined,
+ no_auth: !!this.listenForm.no_auth,
autostart: true,
};
},
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 24bd9dcb..abfacb98 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1228,7 +1228,12 @@
"kernel_iface_picker_title": "Schnittstellen auf diesem Rechner",
"kernel_iface_loading": "Laden…",
"kernel_iface_picker_help": "Optional ein Kernel-Interface-Name, oder leer lassen und nur über Listen-IP binden. Klick füllt das Feld.",
- "auto_iface_ifname_chips_hint": "Schnittstellen dieses Rechners: Klicken zum Hinzufügen oder Entfernen im Feld darüber."
+ "auto_iface_ifname_chips_hint": "Schnittstellen dieses Rechners: Klicken zum Hinzufügen oder Entfernen im Feld darüber.",
+ "i2p_requirements_title": "I2P-Schnittstellenregeln",
+ "i2p_requirements_body": "Nur eine I2P-Schnittstelle ist erlaubt. Aktivieren Sie zuerst den Transportmodus in den Einstellungen und fügen Sie I2P zuletzt hinzu. I2P nicht aus einer Konfigurationsdatei importieren und nicht im Roh-Editor bearbeiten. Änderungen, die I2P in der Mitte lassen, werden beim Speichern und Start automatisch repariert.",
+ "i2p_transport_required": "Aktivieren Sie den Transportmodus in den Einstellungen, bevor Sie eine I2P-Schnittstelle hinzufügen.",
+ "i2p_already_exists": "Es existiert bereits eine I2P-Schnittstelle. Entfernen Sie sie, bevor Sie eine weitere hinzufügen.",
+ "i2p_import_forbidden": "I2P-Schnittstellen können nicht aus einer Datei importiert werden. Fügen Sie I2P nur über Schnittstelle hinzufügen hinzu."
},
"map": {
"title": "Karte",
@@ -2490,6 +2495,7 @@
"command_placeholder": "/bin/bash --login",
"mirror_exit_code": "Exit-Code spiegeln (-m)",
"no_id": "Keine lokale Identität (-N)",
+ "no_auth": "Beliebige Identität erlauben (-n)",
"create_and_start": "Erstellen und starten",
"session_output": "Sitzungsausgabe",
"no_command_yet": "Noch kein Befehl gestartet",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index d65bd047..dab06fae 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1150,6 +1150,11 @@
"community_presets_refresh": "Refresh community presets from directory.rns.recipes",
"community_presets_refreshed": "Updated {count} community interface preset(s)",
"community_presets_refresh_failed": "Could not refresh community presets",
+ "i2p_requirements_title": "I2P interface rules",
+ "i2p_requirements_body": "Only one I2P interface is allowed. Enable Transport Mode in Settings first, then add I2P last. Do not import I2P from a config file or edit it in the raw config editor. Changes that leave I2P mid-list are repaired automatically on save and startup.",
+ "i2p_transport_required": "Enable Transport Mode in Settings before adding an I2P interface.",
+ "i2p_already_exists": "An I2P interface already exists. Remove it before adding another.",
+ "i2p_import_forbidden": "I2P interfaces cannot be imported from a file. Add I2P only through Add Interface.",
"find_more_nodes": "Find more nodes",
"quick_import": "Quick Import",
"quick_import_paste_hint": "Paste raw config",
@@ -2793,6 +2798,7 @@
"command_placeholder": "/bin/bash --login",
"mirror_exit_code": "Mirror exit code (-m)",
"no_id": "No local identity (-N)",
+ "no_auth": "Allow any identity (-n)",
"create_and_start": "Create and start",
"session_output": "Session output",
"no_command_yet": "No command started yet",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 84edf35c..fc085184 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1176,7 +1176,12 @@
"kernel_iface_picker_title": "Interfaces en este equipo",
"kernel_iface_loading": "Cargando…",
"kernel_iface_picker_help": "“Dispositivo” es opcional: un nombre de interfaz del kernel, o vacío para enlazar solo con la IP de escucha. Pulse una fila para rellenar.",
- "auto_iface_ifname_chips_hint": "Interfaces de este equipo: pulse para añadir o quitar nombres en el campo de arriba."
+ "auto_iface_ifname_chips_hint": "Interfaces de este equipo: pulse para añadir o quitar nombres en el campo de arriba.",
+ "i2p_requirements_title": "Reglas de la interfaz I2P",
+ "i2p_requirements_body": "Solo se permite una interfaz I2P. Active primero el modo de transporte en Ajustes y añada I2P al final. No importe I2P desde un archivo de configuración ni la edite en el editor en bruto. Los cambios que dejen I2P en medio se reparan automáticamente al guardar y al iniciar.",
+ "i2p_transport_required": "Active el modo de transporte en Ajustes antes de añadir una interfaz I2P.",
+ "i2p_already_exists": "Ya existe una interfaz I2P. Elimínela antes de añadir otra.",
+ "i2p_import_forbidden": "Las interfaces I2P no se pueden importar desde un archivo. Añada I2P solo desde Añadir interfaz."
},
"map": {
"title": "Mapa",
@@ -2605,6 +2610,7 @@
"command_placeholder": "/bin/bash --login",
"mirror_exit_code": "Reflejar código de salida (-m)",
"no_id": "Sin identidad local (-N)",
+ "no_auth": "Permitir cualquier identidad (-n)",
"create_and_start": "Crear e iniciar",
"session_output": "Salida de la sesión",
"no_command_yet": "Aún no se ha iniciado ningún comando",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index c9d7de3c..bce38596 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1176,7 +1176,12 @@
"kernel_iface_picker_title": "Sovittimet tällä isännällä",
"kernel_iface_loading": "Ladataan…",
"kernel_iface_picker_help": "Laite on valinnainen: yksi ytimen sovitinnimi, tai jätä tyhjäksi sitoaksesi ainoastaan kuuntelu-IP:n välityksellä. Klikkaa riviä täyttääksesi kentän.",
- "auto_iface_ifname_chips_hint": "Tämän isännän sovittimet: klikkaa lisätäksesi tai poistaaksesi nimiä yllä olevista kentistä."
+ "auto_iface_ifname_chips_hint": "Tämän isännän sovittimet: klikkaa lisätäksesi tai poistaaksesi nimiä yllä olevista kentistä.",
+ "i2p_requirements_title": "I2P-liittymän säännöt",
+ "i2p_requirements_body": "Vain yksi I2P-liittymä on sallittu. Ota ensin välitystila käyttöön asetuksissa ja lisää I2P viimeiseksi. Älä tuo I2P:tä asetustiedostosta äläkä muokkaa sitä raakaeditorissa. Muutokset, jotka jättävät I2P:n keskelle, korjataan automaattisesti tallennuksessa ja käynnistyksessä.",
+ "i2p_transport_required": "Ota välitystila käyttöön asetuksissa ennen I2P-liittymän lisäämistä.",
+ "i2p_already_exists": "I2P-liittymä on jo olemassa. Poista se ennen uuden lisäämistä.",
+ "i2p_import_forbidden": "I2P-liittymiä ei voi tuoda tiedostosta. Lisää I2P vain Lisää liittymä -sivulta."
},
"map": {
"title": "Kartta",
@@ -2793,6 +2798,7 @@
"command_placeholder": "/bin/bash --login",
"mirror_exit_code": "Peilaa poistumiskoodi (-m)",
"no_id": "Ei paikallista identiteettiä (-N)",
+ "no_auth": "Salli mikä tahansa identiteetti (-n)",
"create_and_start": "Luo ja käynnistä",
"session_output": "Istunnon tuloste",
"no_command_yet": "Komentoja ei ole vielä käynnistetty",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index b6e8d625..b555cf14 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1176,7 +1176,12 @@
"kernel_iface_picker_title": "Interfaces sur cette machine",
"kernel_iface_loading": "Chargement…",
"kernel_iface_picker_help": "Le champ Périphérique est optionnel : un seul nom d'interface noyau, ou laisser vide pour lier via l'IP d'écoute uniquement. Cliquez sur une ligne pour remplir.",
- "auto_iface_ifname_chips_hint": "Interfaces sur cette machine : cliquez pour ajouter ou retirer des noms dans le champ ci-dessus."
+ "auto_iface_ifname_chips_hint": "Interfaces sur cette machine : cliquez pour ajouter ou retirer des noms dans le champ ci-dessus.",
+ "i2p_requirements_title": "Règles de l'interface I2P",
+ "i2p_requirements_body": "Une seule interface I2P est autorisée. Activez d'abord le mode transport dans les paramètres, puis ajoutez I2P en dernier. N'importez pas I2P depuis un fichier de configuration et ne la modifiez pas dans l'éditeur brut. Les changements qui laissent I2P au milieu sont réparés automatiquement à l'enregistrement et au démarrage.",
+ "i2p_transport_required": "Activez le mode transport dans les paramètres avant d'ajouter une interface I2P.",
+ "i2p_already_exists": "Une interface I2P existe déjà. Supprimez-la avant d'en ajouter une autre.",
+ "i2p_import_forbidden": "Les interfaces I2P ne peuvent pas être importées depuis un fichier. Ajoutez I2P uniquement via Ajouter une interface."
},
"map": {
"title": "Carte",
@@ -2605,6 +2610,7 @@
"command_placeholder": "/bin/bash --login",
"mirror_exit_code": "Code de sortie miroir (-m)",
"no_id": "Aucune identité locale (-N)",
+ "no_auth": "Autoriser toute identité (-n)",
"create_and_start": "Créer et démarrer",
"session_output": "Sortie de session",
"no_command_yet": "Aucune commande démarrée pour l'instant",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 0b3320b7..d8d61f20 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1228,7 +1228,12 @@
"kernel_iface_picker_title": "Interfacce su questo host",
"kernel_iface_loading": "Caricamento…",
"kernel_iface_picker_help": "Il dispositivo è facoltativo: un solo nome di interfaccia del kernel, o lascia vuoto e lega solo con l'IP di ascolto. Clic su una riga per compilare.",
- "auto_iface_ifname_chips_hint": "Interfacce su questo host: clic per aggiungere o rimuovere nomi nel campo sopra."
+ "auto_iface_ifname_chips_hint": "Interfacce su questo host: clic per aggiungere o rimuovere nomi nel campo sopra.",
+ "i2p_requirements_title": "Regole interfaccia I2P",
+ "i2p_requirements_body": "È consentita una sola interfaccia I2P. Abilita prima la modalità trasporto nelle Impostazioni, poi aggiungi I2P per ultima. Non importare I2P da un file di configurazione e non modificarla nell'editor grezzo. Le modifiche che lasciano I2P a metà elenco vengono riparate automaticamente al salvataggio e all'avvio.",
+ "i2p_transport_required": "Abilita la modalità trasporto nelle Impostazioni prima di aggiungere un'interfaccia I2P.",
+ "i2p_already_exists": "Esiste già un'interfaccia I2P. Rimuovila prima di aggiungerne un'altra.",
+ "i2p_import_forbidden": "Le interfacce I2P non possono essere importate da un file. Aggiungi I2P solo tramite Aggiungi interfaccia."
},
"map": {
"title": "Mappa",
@@ -2657,6 +2662,7 @@
"command_placeholder": "/bin/bash --login",
"mirror_exit_code": "Rifletti codice di uscita (-m)",
"no_id": "Nessuna identità locale (-N)",
+ "no_auth": "Consenti qualsiasi identità (-n)",
"create_and_start": "Crea e avvia",
"session_output": "Output della sessione",
"no_command_yet": "Nessun comando ancora avviato",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index dc35bea4..272d07b9 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1176,7 +1176,12 @@
"kernel_iface_picker_title": "Interfaces op deze host",
"kernel_iface_loading": "Laden…",
"kernel_iface_picker_help": "Apparaat is optioneel: één kernelinterfacenaam, of leeg laten en alleen via listen-IP binden. Klik op een regel om in te vullen.",
- "auto_iface_ifname_chips_hint": "Interfaces op deze host: klik om namen in het veld erboven toe te voegen of te verwijderen."
+ "auto_iface_ifname_chips_hint": "Interfaces op deze host: klik om namen in het veld erboven toe te voegen of te verwijderen.",
+ "i2p_requirements_title": "I2P-interfaceregels",
+ "i2p_requirements_body": "Er is slechts één I2P-interface toegestaan. Schakel eerst transportmodus in bij Instellingen en voeg I2P als laatste toe. Importeer I2P niet uit een configbestand en bewerk het niet in de ruwe editor. Wijzigingen die I2P middenin laten staan worden automatisch hersteld bij opslaan en opstarten.",
+ "i2p_transport_required": "Schakel transportmodus in bij Instellingen voordat je een I2P-interface toevoegt.",
+ "i2p_already_exists": "Er bestaat al een I2P-interface. Verwijder die voordat je er nog een toevoegt.",
+ "i2p_import_forbidden": "I2P-interfaces kunnen niet uit een bestand worden geïmporteerd. Voeg I2P alleen toe via Interface toevoegen."
},
"map": {
"title": "Kaart",
@@ -2605,6 +2610,7 @@
"command_placeholder": "/bin/bash --login",
"mirror_exit_code": "Afsluitcode spiegelen (-m)",
"no_id": "Geen lokale identiteit (-N)",
+ "no_auth": "Elke identiteit toestaan (-n)",
"create_and_start": "Aanmaken en starten",
"session_output": "Sessie-uitvoer",
"no_command_yet": "Nog geen opdracht gestart",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index cc6be9e1..1e295b66 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1228,7 +1228,12 @@
"kernel_iface_picker_title": "Интерфейсы на этом узле",
"kernel_iface_loading": "Загрузка…",
"kernel_iface_picker_help": "Поле «устройство» необязательно: одно имя интерфейса ядра или пусто — привязка только по Listen IP. Нажмите строку, чтобы подставить имя.",
- "auto_iface_ifname_chips_hint": "Интерфейсы этого узла: нажмите, чтобы добавить или убрать имя в поле выше."
+ "auto_iface_ifname_chips_hint": "Интерфейсы этого узла: нажмите, чтобы добавить или убрать имя в поле выше.",
+ "i2p_requirements_title": "Правила интерфейса I2P",
+ "i2p_requirements_body": "Допускается только один интерфейс I2P. Сначала включите режим транспорта в настройках, затем добавьте I2P последним. Не импортируйте I2P из файла конфигурации и не редактируйте его в сыром редакторе. Изменения, оставляющие I2P не последним, автоматически исправляются при сохранении и запуске.",
+ "i2p_transport_required": "Включите режим транспорта в настройках перед добавлением интерфейса I2P.",
+ "i2p_already_exists": "Интерфейс I2P уже существует. Удалите его перед добавлением другого.",
+ "i2p_import_forbidden": "Интерфейсы I2P нельзя импортировать из файла. Добавляйте I2P только через страницу добавления интерфейса."
},
"map": {
"title": "Карта",
@@ -2490,6 +2495,7 @@
"command_placeholder": "/bin/bash --login",
"mirror_exit_code": "Зеркалировать код выхода (-m)",
"no_id": "Нет локальной идентификации (-N)",
+ "no_auth": "Разрешить любую идентичность (-n)",
"create_and_start": "Создать и запустить",
"session_output": "Вывод сеанса",
"no_command_yet": "Команда ещё не запущена",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 5b50c3c1..7057fbb0 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1176,7 +1176,12 @@
"kernel_iface_picker_title": "本机网络接口",
"kernel_iface_loading": "加载中…",
"kernel_iface_picker_help": "“设备”为可选,填写一个内核网卡名,或留空仅按 Listen IP 绑定。点击一行填入输入框。",
- "auto_iface_ifname_chips_hint": "本机接口:点击可在上方字段中添加或移除名称。"
+ "auto_iface_ifname_chips_hint": "本机接口:点击可在上方字段中添加或移除名称。",
+ "i2p_requirements_title": "I2P 接口规则",
+ "i2p_requirements_body": "只允许一个 I2P 接口。请先在设置中启用传输模式,再将 I2P 添加为最后一个接口。不要从配置文件导入 I2P,也不要在原始配置编辑器中修改它。若 I2P 不在末尾,保存和启动时会自动修复。",
+ "i2p_transport_required": "添加 I2P 接口前请先在设置中启用传输模式。",
+ "i2p_already_exists": "已存在 I2P 接口。请先删除后再添加另一个。",
+ "i2p_import_forbidden": "不能从文件导入 I2P 接口。请仅通过“添加接口”页面添加 I2P。"
},
"map": {
"title": "地图",
@@ -2605,6 +2610,7 @@
"command_placeholder": "/bin/bash --login",
"mirror_exit_code": "镜像退出码 (-m)",
"no_id": "无本地身份 (-N)",
+ "no_auth": "允许任意身份 (-n)",
"create_and_start": "创建并启动",
"session_output": "会话输出",
"no_command_yet": "尚未启动命令",
diff --git a/tests/backend/test_rnsh_api.py b/tests/backend/test_rnsh_api.py
index 1624039b..0a86430e 100644
--- a/tests/backend/test_rnsh_api.py
+++ b/tests/backend/test_rnsh_api.py
@@ -28,8 +28,9 @@ def _make_request(json_body=None, match_info=None, query=None):
class _DummySession:
- def __init__(self, session_id="s1"):
+ def __init__(self, session_id="s1", start_error=None):
self.session_id = session_id
+ self._start_error = start_error
def to_dict(self, include_output_tail=False):
return {
@@ -43,6 +44,8 @@ class _DummySession:
}
def start(self):
+ if self._start_error is not None:
+ raise self._start_error
return self.to_dict(include_output_tail=True)
@@ -50,17 +53,23 @@ class _DummyManager:
def __init__(self):
self.created_payload = None
self.sent_text = None
+ self.removed_ids = []
+ self._sessions = {"s2": _DummySession("s2")}
def list_sessions(self):
return {"sessions": [_DummySession("s1").to_dict(include_output_tail=True)]}
def create_session(self, payload):
self.created_payload = payload
- return _DummySession("s2")
+ session = _DummySession("s2")
+ self._sessions[session.session_id] = session
+ return session
def remove_session(self, session_id):
- if session_id == "missing":
+ self.removed_ids.append(session_id)
+ if session_id == "missing" or session_id not in self._sessions:
raise KeyError("missing")
+ self._sessions.pop(session_id, None)
def start_session(self, session_id):
if session_id == "missing":
@@ -179,6 +188,19 @@ def test_rnsh_listen_address_detected_from_output():
assert payload["listen_address"] == "8d7f90d560627da94a312bb96ba5c485"
+def test_rnsh_listen_address_ignores_verbose_identity_hashes():
+ from meshchatx.src.backend.rnsh_manager import RNSHSession
+
+ manager = MagicMock()
+ session = RNSHSession(manager, "s1", {"mode": "listen"})
+ session.append_output(
+ "[Verbose] Identity keys created for <aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa>\n"
+ "[Verbose] Transport instance <bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb> started\n"
+ "[Notice] rnsh listening for commands on <cccccccccccccccccccccccccccccccc>\n",
+ )
+ assert session.listen_address == "cccccccccccccccccccccccccccccccc"
+
+
def test_rnsh_resize_updates_geometry_without_process():
from meshchatx.src.backend.rnsh_manager import RNSHSession
@@ -359,3 +381,180 @@ async def test_rnsh_session_not_found_returns_404(mock_app):
assert handler is not None
response = await handler(_make_request(match_info={"session_id": "missing"}))
assert response.status == 404
+
+
+@pytest.mark.asyncio
+async def test_rnsh_autostart_failure_removes_orphan_session(mock_app):
+ manager = _DummyManager()
+
+ def _create_failing(payload):
+ manager.created_payload = payload
+ session = _DummySession("orphan", start_error=ValueError("bad destination"))
+ manager._sessions[session.session_id] = session
+ return session
+
+ manager.create_session = _create_failing
+ mock_app.rnsh_manager = manager
+ handler = _find_handler(mock_app, "/api/v1/rnsh/sessions", "POST")
+ assert handler is not None
+ response = await handler(
+ _make_request(
+ json_body={
+ "mode": "connect",
+ "destination": "",
+ "autostart": True,
+ },
+ ),
+ )
+ assert response.status == 400
+ assert "orphan" in manager.removed_ids
+ assert "orphan" not in manager._sessions
+
+
+def test_rnsh_listen_address_detection_notifies_session_change():
+ from meshchatx.src.backend.rnsh_manager import RNSHSession
+
+ manager = MagicMock()
+ session = RNSHSession(manager, "s1", {"mode": "listen"})
+ session.append_output(
+ "[Notice] rnsh listening for commands on <8d7f90d560627da94a312bb96ba5c485>\n",
+ )
+ assert session.listen_address == "8d7f90d560627da94a312bb96ba5c485"
+ manager._on_session_change.assert_called()
+
+
+def test_rnsh_clear_output_resets_output_seq():
+ from meshchatx.src.backend.rnsh_manager import RNSHSession
+
+ manager = MagicMock()
+ session = RNSHSession(manager, "s1", {"mode": "listen"})
+ session.append_output("one")
+ session.append_output("two")
+ assert session._output_seq == 2
+ session.clear_output()
+ assert session._output_seq == 0
+ assert session.output_since(0) == {"chunks": [], "next_cursor": 0}
+
+
+def test_rnsh_stop_returns_stopped_status(monkeypatch):
+ from meshchatx.src.backend.rnsh_manager import RNSHSession
+
+ class FakeProc:
+ def __init__(self):
+ self.pid = 42
+ self._alive = True
+ self.returncode = None
+
+ def poll(self):
+ return None if self._alive else self.returncode
+
+ def terminate(self):
+ self._alive = False
+ self.returncode = 0
+
+ def kill(self):
+ self._alive = False
+ self.returncode = -9
+
+ def wait(self, timeout=None):
+ self._alive = False
+ if self.returncode is None:
+ self.returncode = 0
+ return self.returncode
+
+ manager = MagicMock()
+ session = RNSHSession(manager, "s1", {"mode": "listen"})
+ proc = FakeProc()
+ session._process = proc
+ session.pid = proc.pid
+ session.status = RNSHSession.STATUS_RUNNING
+
+ payload = session.stop()
+ assert payload["status"] == RNSHSession.STATUS_STOPPED
+ assert payload["pid"] is None
+ assert session.status == RNSHSession.STATUS_STOPPED
+
+
+def test_rnsh_waiter_does_not_clobber_restarted_process():
+ import threading
+ import time
+
+ from meshchatx.src.backend.rnsh_manager import RNSHSession
+
+ class FakeProc:
+ def __init__(self, delay=0.2):
+ self.pid = id(self) % 100000
+ self._delay = delay
+ self._alive = True
+ self.returncode = None
+
+ def poll(self):
+ return None if self._alive else self.returncode
+
+ def wait(self, timeout=None):
+ time.sleep(self._delay)
+ self._alive = False
+ self.returncode = 0
+ return 0
+
+ manager = MagicMock()
+ session = RNSHSession(manager, "s1", {"mode": "listen"})
+ old = FakeProc(delay=0.25)
+ session._process = old
+ session.pid = old.pid
+ session.status = RNSHSession.STATUS_RUNNING
+
+ waiter = threading.Thread(target=session._waiter_loop, args=(old,), daemon=True)
+ waiter.start()
+ time.sleep(0.05)
+
+ new = FakeProc(delay=30)
+ with session._lock:
+ session._process = new
+ session.pid = new.pid
+ session.status = RNSHSession.STATUS_RUNNING
+ session._stop_requested = False
+
+ waiter.join(timeout=2)
+ assert session.status == RNSHSession.STATUS_RUNNING
+ assert session._process is new
+ assert session.pid == new.pid
+ assert new.poll() is None
+
+
+def test_rnsh_start_failure_sets_failed_status(monkeypatch):
+ from meshchatx.src.backend import rnsh_manager as rnsh_mod
+
+ manager = MagicMock()
+ session = rnsh_mod.RNSHSession(
+ manager,
+ "s1",
+ {"mode": "connect", "destination": "aabbccddeeff0011"},
+ )
+ monkeypatch.setattr(
+ rnsh_mod.RNSHSession,
+ "_supports_pty",
+ staticmethod(lambda: False),
+ )
+ monkeypatch.setattr(
+ rnsh_mod.subprocess,
+ "Popen",
+ lambda *args, **kwargs: (_ for _ in ()).throw(OSError("boom")),
+ )
+ with pytest.raises(OSError, match="boom"):
+ session.start()
+ assert session.status == rnsh_mod.RNSHSession.STATUS_FAILED
+ assert session.last_error == "boom"
+ assert session.pid is None
+
+
+def test_rnsh_manager_save_is_atomic(tmp_path):
+ from meshchatx.src.backend.rnsh_manager import RNSHManager
+
+ manager = RNSHManager(str(tmp_path))
+ manager.create_session({"mode": "listen", "name": "atomic"})
+ store = tmp_path / "rnsh_sessions.json"
+ assert store.exists()
+ assert not (tmp_path / "rnsh_sessions.json.tmp").exists()
+ data = json.loads(store.read_text(encoding="utf-8"))
+ assert len(data["sessions"]) == 1
diff --git a/tests/backend/test_rnsh_live.py b/tests/backend/test_rnsh_live.py
index 6b0025d4..3e91c16a 100644
--- a/tests/backend/test_rnsh_live.py
+++ b/tests/backend/test_rnsh_live.py
@@ -11,9 +11,11 @@ Enable with: MESHCHAT_LIVE_RNSH=1
from __future__ import annotations
+import contextlib
import importlib.util
import os
import shutil
+import socket
import tempfile
import textwrap
import time
@@ -34,10 +36,14 @@ class _LiveManager:
def __init__(self, reticulum_config_dir: str):
self.reticulum_config_dir = reticulum_config_dir
self.changes = 0
+ self.outputs = 0
def _on_session_change(self, _session):
self.changes += 1
+ def _on_session_output(self, _session, _chunk):
+ self.outputs += 1
+
def save(self):
return None
@@ -167,3 +173,174 @@ def test_rnsh_live_meshchatx_run_module_launcher_starts_listener(monkeypatch, tm
finally:
session.stop()
shutil.rmtree(tmpdir, ignore_errors=True)
+
+
+def _write_tcp_pair(listen_dir: str, conn_dir: str, port: int) -> None:
+ listen_cfg = textwrap.dedent(
+ f"""\
+ [reticulum]
+ enable_transport = Yes
+ share_instance = No
+ shared_instance_port = {37000 + (port % 1000)}
+ instance_name = rnsh_live_listen_{port}
+ panic_on_interface_error = No
+
+ [logging]
+ loglevel = 4
+
+ [interfaces]
+ [[TCP Server]]
+ type = TCPServerInterface
+ enabled = Yes
+ listen_ip = 127.0.0.1
+ listen_port = {port}
+ """
+ )
+ conn_cfg = textwrap.dedent(
+ f"""\
+ [reticulum]
+ enable_transport = Yes
+ share_instance = No
+ shared_instance_port = {38000 + (port % 1000)}
+ instance_name = rnsh_live_conn_{port}
+ panic_on_interface_error = No
+
+ [logging]
+ loglevel = 4
+
+ [interfaces]
+ [[TCP Client]]
+ type = TCPClientInterface
+ enabled = Yes
+ target_host = 127.0.0.1
+ target_port = {port}
+ """
+ )
+ os.makedirs(listen_dir, exist_ok=True)
+ os.makedirs(conn_dir, exist_ok=True)
+ with open(os.path.join(listen_dir, "config"), "w", encoding="utf-8") as handle:
+ handle.write(listen_cfg)
+ with open(os.path.join(conn_dir, "config"), "w", encoding="utf-8") as handle:
+ handle.write(conn_cfg)
+
+
+def _wait_for_output(
+ session: RNSHSession,
+ needle: str,
+ timeout: float = 35.0,
+) -> str:
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ payload = session.to_dict(include_output_tail=True)
+ text = payload.get("output_text") or ""
+ if needle in text:
+ return text
+ if session.status == RNSHSession.STATUS_FAILED:
+ raise AssertionError(
+ f"session failed before output {needle!r}: {session.last_error!r} "
+ f"output={text!r}",
+ )
+ if (
+ session.status != RNSHSession.STATUS_RUNNING
+ and needle not in text
+ and session._process is None
+ ):
+ payload = session.to_dict(include_output_tail=True)
+ text = payload.get("output_text") or ""
+ if needle in text:
+ return text
+ raise AssertionError(
+ f"session stopped before output {needle!r}; status={session.status} "
+ f"exit={session.last_exit_code} output={text!r}",
+ )
+ time.sleep(0.25)
+ payload = session.to_dict(include_output_tail=True)
+ raise AssertionError(
+ f"timed out waiting for {needle!r}; status={session.status} "
+ f"output={payload.get('output_text')!r}",
+ )
+
+
+@pytest.mark.integration
+@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RNSH=1 to run live RNSh tests")
+@pytest.mark.skipif(
+ not _RNSH_AVAILABLE, reason="RNS.Utilities.rnsh.rnsh is not installed"
+)
+@pytest.mark.skipif(os.name != "posix", reason="Full connect e2e requires a PTY")
+def test_rnsh_live_listen_connect_echo_roundtrip():
+ """Full initiator/listener path over a local TCP Reticulum link.
+
+ Connect mode must use a real PTY: rnsh's initiator registers stdin with
+ asyncio, which fails under pipe/DEVNULL (PermissionError) and never runs
+ the remote command.
+ """
+ sock = socket.socket()
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ sock.close()
+
+ listen_dir = tempfile.mkdtemp(prefix="meshchat_rnsh_e2e_listen_")
+ conn_dir = tempfile.mkdtemp(prefix="meshchat_rnsh_e2e_conn_")
+ _write_tcp_pair(listen_dir, conn_dir, port)
+
+ listen_manager = _LiveManager(listen_dir)
+ conn_manager = _LiveManager(conn_dir)
+ listen = RNSHSession(
+ listen_manager,
+ "e2e-listen",
+ {
+ "mode": "listen",
+ "no_auth": True,
+ "quiet": 1,
+ "config_path": listen_dir,
+ "announce_period": 2,
+ },
+ )
+ connect = RNSHSession(
+ conn_manager,
+ "e2e-connect",
+ {
+ "mode": "connect",
+ "destination": "pending",
+ "config_path": conn_dir,
+ "remote_command": "echo MESHCHAT_RNSH_E2E_OK",
+ "quiet": 1,
+ "timeout": 25,
+ "mirror": True,
+ },
+ )
+ try:
+ listen.start()
+ address = _wait_for_listen_address(listen, timeout=30.0)
+ listen_text = (
+ listen.to_dict(include_output_tail=True).get("output_text") or ""
+ ).lower()
+ assert f"listening for commands on <{address}>" in listen_text
+
+ # Allow TCP link + announce to settle before the initiator path request.
+ time.sleep(2.0)
+
+ connect.config["destination"] = address
+ connect.start()
+ assert connect.status == RNSHSession.STATUS_RUNNING
+ assert connect._master_fd is not None
+
+ output = _wait_for_output(connect, "MESHCHAT_RNSH_E2E_OK", timeout=35.0)
+ assert "MESHCHAT_RNSH_E2E_OK" in output
+
+ deadline = time.time() + 15.0
+ while time.time() < deadline and connect.status == RNSHSession.STATUS_RUNNING:
+ time.sleep(0.2)
+ assert connect.status in (
+ RNSHSession.STATUS_STOPPED,
+ RNSHSession.STATUS_FAILED,
+ )
+ if connect.last_exit_code is not None:
+ assert connect.last_exit_code == 0
+ finally:
+ with contextlib.suppress(Exception):
+ connect.stop()
+ with contextlib.suppress(Exception):
+ listen.stop()
+ shutil.rmtree(listen_dir, ignore_errors=True)
+ shutil.rmtree(conn_dir, ignore_errors=True)
diff --git a/tests/frontend/RNSHManagerPage.test.js b/tests/frontend/RNSHManagerPage.test.js
index 8a1b47e0..8ad5b155 100644
--- a/tests/frontend/RNSHManagerPage.test.js
+++ b/tests/frontend/RNSHManagerPage.test.js
@@ -132,4 +132,59 @@ describe("RNSHManagerPage.vue", () => {
wrapper.vm.selectSession(SESSION_ID);
expect(wrapper.vm.mobileSessionsOpen).toBe(false);
});
+
+ it("creates a listen session with no_auth enabled by default", async () => {
+ window.api.post.mockResolvedValueOnce({
+ data: { session: makeSession({ id: "listen-1", mode: "listen", name: "Listener" }) },
+ });
+
+ const wrapper = mount(RNSHManagerPage, { global: mountToolsPageGlobals() });
+ await vi.waitFor(() => expect(wrapper.vm.sessions.length).toBe(1));
+
+ expect(wrapper.vm.listenForm.no_auth).toBe(true);
+ wrapper.vm.listenForm.name = "Listener";
+ await wrapper.vm.createListenSession();
+
+ expect(window.api.post).toHaveBeenCalledWith("/api/v1/rnsh/sessions", {
+ name: "Listener",
+ mode: "listen",
+ allowed_hashes: [],
+ default_command: undefined,
+ config_path: undefined,
+ no_auth: true,
+ autostart: true,
+ });
+ });
+
+ it("keeps longer live output when session reload returns a truncated chunk tail", async () => {
+ const wrapper = mount(RNSHManagerPage, { global: mountToolsPageGlobals() });
+ await vi.waitFor(() => expect(wrapper.vm.sessions.length).toBe(1));
+
+ const live = "LINE_0000\n".repeat(50) + "LINE_TAIL\n";
+ wrapper.vm.outputsBySession[SESSION_ID] = live;
+
+ wrapper.vm.ingestSession(
+ makeSession({
+ output_chunks: [{ seq: 99, text: "LINE_TAIL\n", ts: 2 }],
+ output_text: "LINE_0400\nLINE_TAIL\n",
+ }),
+ );
+
+ expect(wrapper.vm.outputsBySession[SESSION_ID]).toBe(live);
+ });
+
+ it("prefers longer output_text over short output_chunks on ingest", async () => {
+ const wrapper = mount(RNSHManagerPage, { global: mountToolsPageGlobals() });
+ await vi.waitFor(() => expect(wrapper.vm.sessions.length).toBe(1));
+
+ const longText = "full history\n".repeat(20);
+ wrapper.vm.ingestSession(
+ makeSession({
+ output_chunks: [{ seq: 1, text: "tail only\n", ts: 1 }],
+ output_text: longText,
+ }),
+ );
+
+ expect(wrapper.vm.outputsBySession[SESSION_ID]).toBe(longText);
+ });
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────